Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit febce56ccbe5696bb103a3fa8fa0476c1e06e6e8


Parents : 7737194
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-19T05:46:28-05:00

feat: update interface management with new options for fast-flapping, location command, and recursive path requests

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2df57382..b6c4bd5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file.
- Notification sound settings
- LXMFy 1.6.5 vendor refresh, wasmtime, mutation test tasks
- Network visualiser WebGL + WASM renderer (vis-network fallback) and Settings renderer preference
+- Interfaces: internal mode, recursive path requests, announces-from-internal, discovery location command, and Backbone fast-flapping options (RNS 1.3.7 to 1.3.9)
### Changed
@@ -61,6 +62,7 @@ All notable changes to this project will be documented in this file.
- Open conversations mark as read when a new message arrives without needing to reselect the thread
- Startup stage logs no longer print the same stage twice
- Ctrl+C shutdown no longer floods reentrant logging errors from RNS.exit containment
+- RN Status interface mode labels match Reticulum modes again, including Internal
## [4.7.2] - 2026-07-06

diff --git a/meshchatx.rsm b/meshchatx.rsm
index c51b9c4b..a218e045 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 0275a636..4849b47c 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -6793,6 +6793,17 @@ class ReticulumMeshChat:
data,
"prefer_ipv6",
)
+ flap_error = InterfaceEditor.apply_backbone_fast_flapping(
+ interface_details,
+ data,
+ )
+ if flap_error is not None:
+ return web.json_response(
+ {
+ "message": flap_error,
+ },
+ status=422,
+ )
else:
remote = data.get("remote") or data.get("target_host")
if remote is None or str(remote).strip() == "":
@@ -7326,6 +7337,18 @@ class ReticulumMeshChat:
):
InterfaceEditor.update_value(interface_details, data, discovery_key)
+ location_cmd_error = InterfaceEditor.apply_location_cmd(
+ interface_details,
+ data,
+ )
+ if location_cmd_error is not None:
+ return web.json_response(
+ {
+ "message": location_cmd_error,
+ },
+ status=422,
+ )
+
if interface_type == "TCPClientInterface" or (
interface_type == "BackboneInterface"
and str(interface_details.get("remote") or "").strip() != ""
@@ -7344,7 +7367,38 @@ class ReticulumMeshChat:
# set common interface options
InterfaceEditor.update_value(interface_details, data, "bitrate")
- InterfaceEditor.update_value(interface_details, data, "mode")
+ mode_error = InterfaceEditor.apply_interface_mode(interface_details, data)
+ if mode_error is not None:
+ return web.json_response(
+ {
+ "message": mode_error,
+ },
+ status=422,
+ )
+ recursive_prs_error = InterfaceEditor.apply_yes_no_option(
+ interface_details,
+ data,
+ "recursive_prs",
+ )
+ if recursive_prs_error is not None:
+ return web.json_response(
+ {
+ "message": recursive_prs_error,
+ },
+ status=422,
+ )
+ announces_error = InterfaceEditor.apply_yes_no_option(
+ interface_details,
+ data,
+ "announces_from_internal",
+ )
+ if announces_error is not None:
+ return web.json_response(
+ {
+ "message": announces_error,
+ },
+ status=422,
+ )
InterfaceEditor.update_value(interface_details, data, "network_name")
InterfaceEditor.update_value(interface_details, data, "passphrase")
InterfaceEditor.update_value(interface_details, data, "ifac_size")
@@ -7543,6 +7597,16 @@ class ReticulumMeshChat:
},
status=422,
)
+ import_option_error = (
+ InterfaceEditor.sanitize_imported_rns_options(iface_body)
+ )
+ if import_option_error is not None:
+ return web.json_response(
+ {
+ "message": import_option_error,
+ },
+ status=422,
+ )
if iface_type in ("RNodeInterface", "RNodeIPInterface"):
freq = iface_body.get("frequency")
if freq is not None and freq != "":

diff --git a/meshchatx/src/backend/interface_editor.py b/meshchatx/src/backend/interface_editor.py
index c905c5ba..8ce5daca 100644
--- a/meshchatx/src/backend/interface_editor.py
+++ b/meshchatx/src/backend/interface_editor.py
@@ -1,11 +1,45 @@
# SPDX-License-Identifier: 0BSD AND MIT
+import os
import re
import RNS
_IPV4_HOST_PORT = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$")
+# Canonical Reticulum interface mode strings (RNS 1.3.7+ includes internal).
+ALLOWED_INTERFACE_MODES = frozenset(
+ {
+ "full",
+ "gateway",
+ "gw",
+ "access_point",
+ "accesspoint",
+ "ap",
+ "pointtopoint",
+ "ptp",
+ "roaming",
+ "boundary",
+ "internal",
+ }
+)
+
+# Prefer writing the long form when aliases are supplied via the API.
+_INTERFACE_MODE_CANONICAL = {
+ "gw": "gateway",
+ "accesspoint": "access_point",
+ "ap": "access_point",
+ "pointtopoint": "pointtopoint",
+ "ptp": "pointtopoint",
+}
+
+_YES_NO_TRUE = frozenset({"true", "yes", "1", "y", "on"})
+_YES_NO_FALSE = frozenset({"false", "no", "0", "n", "off"})
+
+# location_cmd is executed by RNS Discovery via subprocess.run([path]).
+# Reject shell metacharacters and relative traversal before persisting.
+_LOCATION_CMD_FORBIDDEN = re.compile(r"[\x00-\x1f\x7f;&|`$<>\\\"'*?\[\]{}()!#]")
+
def normalize_rnode_tcp_port(port: str) -> str:
"""Normalize RNodeInterface port when using tcp://.
@@ -162,3 +196,214 @@ class InterfaceEditor:
# otherwise remove existing value
interface_details.pop(key, None)
+
+ @staticmethod
+ def normalize_interface_mode(value) -> str | None:
+ """Return a canonical Reticulum mode string, or None when unset."""
+ if value is None or value == "":
+ return None
+ mode = str(value).strip().lower()
+ if mode not in ALLOWED_INTERFACE_MODES:
+ return None
+ return _INTERFACE_MODE_CANONICAL.get(mode, mode)
+
+ @staticmethod
+ def apply_interface_mode(interface_details: dict, data: dict) -> str | None:
+ """Persist mode when valid. Return an API error message otherwise."""
+ if "mode" not in data:
+ return None
+ value = data.get("mode")
+ if value is None or value == "":
+ interface_details.pop("mode", None)
+ return None
+ mode = InterfaceEditor.normalize_interface_mode(value)
+ if mode is None:
+ return (
+ "mode must be one of: full, gateway, access_point, "
+ "pointtopoint, roaming, boundary, internal"
+ )
+ interface_details["mode"] = mode
+ return None
+
+ @staticmethod
+ def request_yes_no(value) -> str | None:
+ """Map common truthy/falsey request values to Reticulum yes/no."""
+ if value is None or value == "":
+ return None
+ if isinstance(value, bool):
+ return "yes" if value else "no"
+ s = str(value).strip().lower()
+ if s in _YES_NO_TRUE:
+ return "yes"
+ if s in _YES_NO_FALSE:
+ return "no"
+ return None
+
+ @staticmethod
+ def apply_yes_no_option(
+ interface_details: dict,
+ data: dict,
+ key: str,
+ *,
+ default_when_missing: str | None = None,
+ ) -> str | None:
+ """Persist a Reticulum yes/no option. Return error text on bad input.
+
+ When the key is absent from data, leave existing config alone unless
+ default_when_missing is set (then write that yes/no or pop when None).
+ """
+ if key not in data:
+ if default_when_missing is None:
+ return None
+ if default_when_missing in ("yes", "no"):
+ interface_details[key] = default_when_missing
+ else:
+ interface_details.pop(key, None)
+ return None
+ yn = InterfaceEditor.request_yes_no(data.get(key))
+ if yn is None:
+ interface_details.pop(key, None)
+ raw = data.get(key)
+ if raw is None or raw == "":
+ return None
+ return f"{key} must be a boolean or yes/no value"
+ interface_details[key] = yn
+ return None
+
+ @staticmethod
+ def validate_location_cmd(value) -> str | None:
+ """Return an error when location_cmd is unsafe for RNS Discovery exec."""
+ if value is None or value == "":
+ return None
+ raw = str(value).strip()
+ if not raw:
+ return None
+ if _LOCATION_CMD_FORBIDDEN.search(raw):
+ return (
+ "location_cmd must be an absolute executable path without "
+ "shell metacharacters or control characters"
+ )
+ if ".." in raw.replace("\\", "/").split("/"):
+ return "location_cmd must not contain parent-directory segments"
+ expanded = os.path.expanduser(raw)
+ if not os.path.isabs(expanded):
+ return "location_cmd must be an absolute path or start with ~/"
+ return None
+
+ @staticmethod
+ def apply_location_cmd(interface_details: dict, data: dict) -> str | None:
+ """Persist discovery location_cmd when valid."""
+ if "location_cmd" not in data:
+ return None
+ value = data.get("location_cmd")
+ if value is None or value == "":
+ interface_details.pop("location_cmd", None)
+ return None
+ error = InterfaceEditor.validate_location_cmd(value)
+ if error is not None:
+ return error
+ interface_details["location_cmd"] = os.path.normpath(
+ os.path.expanduser(str(value).strip()),
+ )
+ return None
+
+ @staticmethod
+ def apply_positive_number(
+ interface_details: dict,
+ data: dict,
+ key: str,
+ *,
+ as_int: bool = False,
+ minimum: float = 0,
+ maximum: float | None = None,
+ ) -> str | None:
+ """Persist a numeric option with bounds. Return error text if invalid."""
+ if key not in data:
+ return None
+ value = data.get(key)
+ if value is None or value == "":
+ interface_details.pop(key, None)
+ return None
+ try:
+ number = int(value) if as_int else float(value)
+ except (TypeError, ValueError):
+ return f"{key} must be a number"
+ if number < minimum:
+ return f"{key} must be at least {minimum}"
+ if maximum is not None and number > maximum:
+ return f"{key} must be at most {maximum}"
+ interface_details[key] = int(number) if as_int else number
+ return None
+
+ @staticmethod
+ def apply_backbone_fast_flapping(
+ interface_details: dict,
+ data: dict,
+ ) -> str | None:
+ """Persist BackboneInterface fast-flapping options (RNS 1.3.9)."""
+ err = InterfaceEditor.apply_yes_no_option(
+ interface_details,
+ data,
+ "block_fast_flapping",
+ )
+ if err:
+ return err
+ err = InterfaceEditor.apply_positive_number(
+ interface_details,
+ data,
+ "fast_flapping_block_time",
+ as_int=True,
+ minimum=1,
+ maximum=60 * 24 * 30,
+ )
+ if err:
+ return err
+ err = InterfaceEditor.apply_positive_number(
+ interface_details,
+ data,
+ "fast_flapping_threshold",
+ as_int=False,
+ minimum=0.1,
+ maximum=3600,
+ )
+ if err:
+ return err
+ return InterfaceEditor.apply_positive_number(
+ interface_details,
+ data,
+ "fast_flapping_grace",
+ as_int=True,
+ minimum=0,
+ maximum=10_000,
+ )
+
+ @staticmethod
+ def sanitize_imported_rns_options(iface_body: dict) -> str | None:
+ """Normalize/validate RNS 1.3.7+ options on import. Return error or None."""
+ if "mode" in iface_body:
+ mode = InterfaceEditor.normalize_interface_mode(iface_body.get("mode"))
+ if mode is None:
+ return (
+ "Imported interface mode must be one of: full, gateway, "
+ "access_point, pointtopoint, roaming, boundary, internal"
+ )
+ iface_body["mode"] = mode
+ for key in ("recursive_prs", "announces_from_internal", "block_fast_flapping"):
+ if key not in iface_body:
+ continue
+ yn = InterfaceEditor.request_yes_no(iface_body.get(key))
+ if yn is None:
+ return f"Imported interface {key} must be a boolean or yes/no value"
+ iface_body[key] = yn
+ if "location_cmd" in iface_body:
+ loc = iface_body.get("location_cmd")
+ if loc is None or loc == "":
+ iface_body.pop("location_cmd", None)
+ else:
+ error = InterfaceEditor.validate_location_cmd(loc)
+ if error is not None:
+ return error
+ iface_body["location_cmd"] = os.path.normpath(
+ os.path.expanduser(str(loc).strip()),
+ )
+ return None

diff --git a/meshchatx/src/backend/rnstatus_handler.py b/meshchatx/src/backend/rnstatus_handler.py
index afff4f2e..40758939 100644
--- a/meshchatx/src/backend/rnstatus_handler.py
+++ b/meshchatx/src/backend/rnstatus_handler.py
@@ -233,16 +233,19 @@ class RNStatusHandler:
}
mode = ifstat.get("mode")
- if mode == 1:
+ iface_modes = RNS.Interfaces.Interface.Interface
+ if mode == iface_modes.MODE_ACCESS_POINT:
formatted_if["mode"] = "Access Point"
- elif mode == 2:
+ elif mode == iface_modes.MODE_POINT_TO_POINT:
formatted_if["mode"] = "Point-to-Point"
- elif mode == 3:
+ elif mode == iface_modes.MODE_ROAMING:
formatted_if["mode"] = "Roaming"
- elif mode == 4:
+ elif mode == iface_modes.MODE_BOUNDARY:
formatted_if["mode"] = "Boundary"
- elif mode == 5:
+ elif mode == iface_modes.MODE_GATEWAY:
formatted_if["mode"] = "Gateway"
+ elif mode == iface_modes.MODE_INTERNAL:
+ formatted_if["mode"] = "Internal"
else:
formatted_if["mode"] = "Full"

diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 48741edc..faab8312 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -445,6 +445,57 @@
>Prefer IPv6</FormLabel
>
</div>
+ <div
+ class="rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30 p-3 space-y-3"
+ >
+ <div class="flex items-start justify-between gap-4">
+ <div class="min-w-0">
+ <FormLabel class="glass-label mb-0!">{{
+ $t("interfaces.block_fast_flapping_label")
+ }}</FormLabel>
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
+ {{ $t("interfaces.block_fast_flapping_hint") }}
+ </p>
+ </div>
+ <Toggle v-model="newInterfaceBlockFastFlapping" />
+ </div>
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.fast_flapping_block_time_label")
+ }}</FormLabel>
+ <input
+ v-model.number="newInterfaceFastFlappingBlockTime"
+ type="number"
+ min="1"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.fast_flapping_threshold_label")
+ }}</FormLabel>
+ <input
+ v-model.number="newInterfaceFastFlappingThreshold"
+ type="number"
+ min="0.1"
+ step="0.1"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.fast_flapping_grace_label")
+ }}</FormLabel>
+ <input
+ v-model.number="newInterfaceFastFlappingGrace"
+ type="number"
+ min="0"
+ class="input-field"
+ />
+ </div>
+ </div>
+ </div>
</div>
</div>
@@ -1557,6 +1608,21 @@
/>
</div>
</div>
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.location_cmd_label")
+ }}</FormLabel>
+ <input
+ v-model="discovery.location_cmd"
+ type="text"
+ :placeholder="$t('interfaces.location_cmd_placeholder')"
+ class="input-field font-mono text-xs"
+ autocomplete="off"
+ />
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
+ {{ $t("interfaces.location_cmd_hint") }}
+ </p>
+ </div>
<div class="grid grid-cols-2 gap-4">
<div>
<FormLabel class="glass-label">Discovery stamp value</FormLabel>
@@ -1663,12 +1729,25 @@
<div>
<FormLabel class="glass-label">Interface Mode</FormLabel>
<select v-model="sharedInterfaceSettings.mode" class="input-field">
- <option :value="undefined">Default (Full)</option>
- <option value="full">Full</option>
- <option value="gateway">Gateway</option>
- <option value="access_point">Access Point</option>
- <option value="roaming">Roaming</option>
- <option value="boundary">Boundary</option>
+ <option :value="undefined">{{
+ $t("interfaces.mode_default_full")
+ }}</option>
+ <option value="full">{{ $t("interfaces.mode_full") }}</option>
+ <option value="gateway">{{
+ $t("interfaces.mode_gateway")
+ }}</option>
+ <option value="access_point">{{
+ $t("interfaces.mode_access_point")
+ }}</option>
+ <option value="roaming">{{
+ $t("interfaces.mode_roaming")
+ }}</option>
+ <option value="boundary">{{
+ $t("interfaces.mode_boundary")
+ }}</option>
+ <option value="internal">{{
+ $t("interfaces.mode_internal")
+ }}</option>
</select>
</div>
<div>
@@ -1681,6 +1760,32 @@
/>
</div>
</div>
+ <div class="space-y-4 pt-2">
+ <div class="flex items-start justify-between gap-4">
+ <div class="min-w-0 max-w-md">
+ <FormLabel class="glass-label mb-0!">{{
+ $t("interfaces.recursive_prs_label")
+ }}</FormLabel>
+ <p class="text-xs text-gray-400 mt-1">
+ {{ $t("interfaces.recursive_prs_hint") }}
+ </p>
+ </div>
+ <Toggle v-model="sharedInterfaceSettings.recursive_prs" />
+ </div>
+ <div class="flex items-start justify-between gap-4">
+ <div class="min-w-0 max-w-md">
+ <FormLabel class="glass-label mb-0!">{{
+ $t("interfaces.announces_from_internal_label")
+ }}</FormLabel>
+ <p class="text-xs text-gray-400 mt-1">
+ {{ $t("interfaces.announces_from_internal_hint") }}
+ </p>
+ </div>
+ <Toggle
+ v-model="sharedInterfaceSettings.announces_from_internal"
+ />
+ </div>
+ </div>
<div class="space-y-4 pt-4 border-t border-gray-100 dark:border-zinc-800">
<FormLabel class="glass-label">Interface Access Code (IFAC)</FormLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -2013,6 +2118,10 @@ export default {
newInterfaceBackboneListenPort: null,
newInterfaceBackboneListenIp: null,
newInterfaceBackboneListenDevice: null,
+ newInterfaceBlockFastFlapping: true,
+ newInterfaceFastFlappingBlockTime: 720,
+ newInterfaceFastFlappingThreshold: 20,
+ newInterfaceFastFlappingGrace: 5,
reticulumInstance: {
share_instance: true,
local_hops_delta: false,
@@ -2033,6 +2142,8 @@ export default {
passphrase: null,
ifac_size: null,
bitrate: null,
+ recursive_prs: false,
+ announces_from_internal: true,
},
discovery: {
@@ -2046,6 +2157,7 @@ export default {
latitude: null,
longitude: null,
height: null,
+ location_cmd: "",
discovery_frequency: null,
discovery_bandwidth: null,
discovery_modulation: null,
@@ -2590,6 +2702,22 @@ export default {
this.newInterfaceBackboneListenIp = iface.listen_ip ?? null;
this.newInterfaceBackboneListenPort = iface.listen_port ?? null;
this.newInterfaceBackboneListenDevice = iface.device ?? null;
+ this.newInterfaceBlockFastFlapping =
+ iface.block_fast_flapping === undefined || iface.block_fast_flapping === null
+ ? true
+ : this.parseBool(iface.block_fast_flapping);
+ this.newInterfaceFastFlappingBlockTime =
+ iface.fast_flapping_block_time != null && iface.fast_flapping_block_time !== ""
+ ? Number(iface.fast_flapping_block_time)
+ : 720;
+ this.newInterfaceFastFlappingThreshold =
+ iface.fast_flapping_threshold != null && iface.fast_flapping_threshold !== ""
+ ? Number(iface.fast_flapping_threshold)
+ : 20;
+ this.newInterfaceFastFlappingGrace =
+ iface.fast_flapping_grace != null && iface.fast_flapping_grace !== ""
+ ? Number(iface.fast_flapping_grace)
+ : 5;
}
if (
@@ -2651,6 +2779,11 @@ export default {
this.sharedInterfaceSettings.bitrate = iface.bitrate;
this.sharedInterfaceSettings.network_name = iface.network_name;
this.sharedInterfaceSettings.passphrase = iface.passphrase;
+ this.sharedInterfaceSettings.recursive_prs = this.parseBool(iface.recursive_prs);
+ this.sharedInterfaceSettings.announces_from_internal =
+ iface.announces_from_internal === undefined || iface.announces_from_internal === null
+ ? true
+ : this.parseBool(iface.announces_from_internal);
if (iface.frequency) {
this.RNodeGHzValue = Math.floor(iface.frequency / 1e9);
@@ -2676,6 +2809,7 @@ export default {
this.discovery.longitude =
iface.longitude != null && iface.longitude !== "" ? Number(iface.longitude) : null;
this.discovery.height = iface.height != null && iface.height !== "" ? Number(iface.height) : null;
+ this.discovery.location_cmd = iface.location_cmd ?? "";
this.discovery.discovery_frequency =
iface.discovery_frequency != null && iface.discovery_frequency !== ""
? Number(iface.discovery_frequency)
@@ -2828,6 +2962,31 @@ export default {
if (config.bitrate) this.sharedInterfaceSettings.bitrate = Number(config.bitrate);
if (config.network_name) this.sharedInterfaceSettings.network_name = config.network_name;
if (config.passphrase) this.sharedInterfaceSettings.passphrase = config.passphrase;
+ if (config.recursive_prs !== undefined && config.recursive_prs !== null && config.recursive_prs !== "") {
+ this.sharedInterfaceSettings.recursive_prs = this.parseBool(config.recursive_prs);
+ }
+ if (
+ config.announces_from_internal !== undefined &&
+ config.announces_from_internal !== null &&
+ config.announces_from_internal !== ""
+ ) {
+ this.sharedInterfaceSettings.announces_from_internal = this.parseBool(
+ config.announces_from_internal
+ );
+ }
+ if (
+ config.block_fast_flapping !== undefined &&
+ config.block_fast_flapping !== null &&
+ config.block_fast_flapping !== ""
+ ) {
+ this.newInterfaceBlockFastFlapping = this.parseBool(config.block_fast_flapping);
+ }
+ if (config.fast_flapping_block_time)
+ this.newInterfaceFastFlappingBlockTime = Number(config.fast_flapping_block_time);
+ if (config.fast_flapping_threshold)
+ this.newInterfaceFastFlappingThreshold = Number(config.fast_flapping_threshold);
+ if (config.fast_flapping_grace)
+ this.newInterfaceFastFlappingGrace = Number(config.fast_flapping_grace);
if (config.discoverable !== undefined && config.discoverable !== null && config.discoverable !== "") {
this.discovery.discoverable = this.parseBool(config.discoverable);
@@ -2843,6 +3002,7 @@ export default {
if (config.latitude) this.discovery.latitude = Number(config.latitude);
if (config.longitude) this.discovery.longitude = Number(config.longitude);
if (config.height) this.discovery.height = Number(config.height);
+ if (config.location_cmd) this.discovery.location_cmd = String(config.location_cmd);
ToastUtils.success(`Imported configuration for "${config.name}"`);
@@ -2913,10 +3073,25 @@ export default {
latitude: discoveryEnabled ? this.numOrNull(config.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(config.longitude) : null,
height: discoveryEnabled ? this.numOrNull(config.height) : null,
+ location_cmd: discoveryEnabled
+ ? config.location_cmd
+ ? String(config.location_cmd).trim() || null
+ : null
+ : null,
discovery_frequency: discoveryEnabled ? this.numOrNull(config.discovery_frequency) : null,
discovery_bandwidth: discoveryEnabled ? this.numOrNull(config.discovery_bandwidth) : null,
discovery_modulation: discoveryEnabled ? this.numOrNull(config.discovery_modulation) : null,
mode: config.mode || null,
+ recursive_prs:
+ config.recursive_prs !== undefined && config.recursive_prs !== null && config.recursive_prs !== ""
+ ? this.parseBool(config.recursive_prs)
+ : false,
+ announces_from_internal:
+ config.announces_from_internal !== undefined &&
+ config.announces_from_internal !== null &&
+ config.announces_from_internal !== ""
+ ? this.parseBool(config.announces_from_internal)
+ : true,
bitrate: this.numOrNull(config.bitrate),
network_name: config.network_name || null,
passphrase: config.passphrase || null,
@@ -2927,6 +3102,34 @@ export default {
config.prefer_ipv6 !== undefined && config.prefer_ipv6 !== null && config.prefer_ipv6 !== ""
? this.parseBool(config.prefer_ipv6)
: null,
+ block_fast_flapping:
+ config.type === "BackboneInterface" &&
+ config.listen_port != null &&
+ String(config.listen_port).trim() !== ""
+ ? config.block_fast_flapping !== undefined &&
+ config.block_fast_flapping !== null &&
+ config.block_fast_flapping !== ""
+ ? this.parseBool(config.block_fast_flapping)
+ : true
+ : null,
+ fast_flapping_block_time:
+ config.type === "BackboneInterface" &&
+ config.listen_port != null &&
+ String(config.listen_port).trim() !== ""
+ ? this.numOrNull(config.fast_flapping_block_time)
+ : null,
+ fast_flapping_threshold:
+ config.type === "BackboneInterface" &&
+ config.listen_port != null &&
+ String(config.listen_port).trim() !== ""
+ ? this.numOrNull(config.fast_flapping_threshold)
+ : null,
+ fast_flapping_grace:
+ config.type === "BackboneInterface" &&
+ config.listen_port != null &&
+ String(config.listen_port).trim() !== ""
+ ? this.numOrNull(config.fast_flapping_grace)
+ : null,
kiss_framing:
config.kiss_framing !== undefined && config.kiss_framing !== null && config.kiss_framing !== ""
? this.parseBool(config.kiss_framing)
@@ -3142,6 +3345,9 @@ export default {
latitude: discoveryEnabled ? this.numOrNull(this.discovery.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(this.discovery.longitude) : null,
height: discoveryEnabled ? this.numOrNull(this.discovery.height) : null,
+ location_cmd: discoveryEnabled
+ ? (this.discovery.location_cmd || "").trim() || null
+ : null,
discovery_frequency: discoveryEnabled
? this.numOrNull(this.discovery.discovery_frequency)
: null,
@@ -3152,6 +3358,9 @@ export default {
? this.numOrNull(this.discovery.discovery_modulation)
: null,
mode: this.sharedInterfaceSettings.mode || null,
+ recursive_prs: this.sharedInterfaceSettings.recursive_prs === true,
+ announces_from_internal:
+ this.sharedInterfaceSettings.announces_from_internal !== false,
bitrate: this.sharedInterfaceSettings.bitrate,
network_name: this.sharedInterfaceSettings.network_name,
passphrase: this.sharedInterfaceSettings.passphrase,
@@ -3194,6 +3403,16 @@ export default {
? (this.newInterfaceBackboneListenDevice || "").trim() || null
: (this.newInterfaceNetworkDevice || "").trim() || null,
prefer_ipv6: this.newInterfacePreferIPV6 === true,
+ block_fast_flapping: isBackboneListener ? this.newInterfaceBlockFastFlapping === true : null,
+ fast_flapping_block_time: isBackboneListener
+ ? this.numOrNull(this.newInterfaceFastFlappingBlockTime)
+ : null,
+ fast_flapping_threshold: isBackboneListener
+ ? this.numOrNull(this.newInterfaceFastFlappingThreshold)
+ : null,
+ fast_flapping_grace: isBackboneListener
+ ? this.numOrNull(this.newInterfaceFastFlappingGrace)
+ : null,
kiss_framing: this.newInterfaceKISSFramingEnabled === true,
i2p_tunneled: this.newInterfaceI2PTunnelingEnabled === true,
connect_timeout: this.numOrNull(this.newInterfaceConnectTimeout),
@@ -3256,10 +3475,13 @@ export default {
latitude: discoveryEnabled ? this.numOrNull(this.discovery.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(this.discovery.longitude) : null,
height: discoveryEnabled ? this.numOrNull(this.discovery.height) : null,
+ location_cmd: discoveryEnabled ? (this.discovery.location_cmd || "").trim() || null : null,
discovery_frequency: discoveryEnabled ? this.numOrNull(this.discovery.discovery_frequency) : null,
discovery_bandwidth: discoveryEnabled ? this.numOrNull(this.discovery.discovery_bandwidth) : null,
discovery_modulation: discoveryEnabled ? this.numOrNull(this.discovery.discovery_modulation) : null,
mode: this.sharedInterfaceSettings.mode || null,
+ recursive_prs: this.sharedInterfaceSettings.recursive_prs === true,
+ announces_from_internal: this.sharedInterfaceSettings.announces_from_internal !== false,
bitrate: this.sharedInterfaceSettings.bitrate,
network_name: this.sharedInterfaceSettings.network_name,
passphrase: this.sharedInterfaceSettings.passphrase,

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index a7bfb658..c5e662f3 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1348,7 +1348,26 @@
"i2p_requirements_body": "Nur eine I2P-Schnittstelle ist erlaubt. Aktivieren Sie zuerst den Transportmodus in den Einstellungen und fügen Sie I2P zuletzt hinzu. I2P nicht aus einer Konfigurationsdatei importieren und nicht im Roh-Editor bearbeiten. Änderungen, die I2P in der Mitte lassen, werden beim Speichern und Start automatisch repariert.",
"i2p_transport_required": "Aktivieren Sie den Transportmodus in den Einstellungen, bevor Sie eine I2P-Schnittstelle hinzufügen.",
"i2p_already_exists": "Es existiert bereits eine I2P-Schnittstelle. Entfernen Sie sie, bevor Sie eine weitere hinzufügen.",
- "i2p_import_forbidden": "I2P-Schnittstellen können nicht aus einer Datei importiert werden. Fügen Sie I2P nur über Schnittstelle hinzufügen hinzu."
+ "i2p_import_forbidden": "I2P-Schnittstellen können nicht aus einer Datei importiert werden. Fügen Sie I2P nur über Schnittstelle hinzufügen hinzu.",
+ "mode_default_full": "Standard (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Rekursive Pfadanfragen",
+ "recursive_prs_hint": "Erlaubt rekursive Pfadfindung auf dieser Schnittstelle unabhängig vom Modus (RNS recursive_prs).",
+ "announces_from_internal_label": "Announces von internen Schnittstellen weiterleiten",
+ "announces_from_internal_hint": "Wenn aus, werden Announces von Internal-Mode-Schnittstellen hier nicht weitergesendet (RNS announces_from_internal).",
+ "location_cmd_label": "Standortbefehl (optional)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Absoluter Pfad zu einem Programm, das latitude,longitude,height ausgibt. Reticulum startet es für Discovery-Announces (RNS location_cmd).",
+ "block_fast_flapping_label": "Schnell wechselnde Clients blockieren",
+ "block_fast_flapping_hint": "Ignoriert Clients, die sich zu schnell verbinden und trennen (RNS 1.3.9 BackboneInterface-Standard).",
+ "fast_flapping_block_time_label": "Sperrzeit (Minuten)",
+ "fast_flapping_threshold_label": "Schwelle (Sekunden)",
+ "fast_flapping_grace_label": "Kulanz-Flaps"
},
"map": {
"title": "Karte",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index e21aa8a8..17ad92b8 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1224,6 +1224,25 @@
"backbone_transport_identity_label": "Transport identity (optional)",
"backbone_transport_identity_placeholder": "e53433e51cde34c42a3245ba3fe1ad69",
"backbone_transport_identity_hint": "Matches Reticulum backbone remote examples that only set remote and target_port. Use the hash when an operator or directory specifies it.",
+ "mode_default_full": "Default (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Recursive path requests",
+ "recursive_prs_hint": "Allow recursive path discovery on this interface regardless of mode (RNS recursive_prs).",
+ "announces_from_internal_label": "Propagate announces from internal interfaces",
+ "announces_from_internal_hint": "When off, announces received on internal-mode interfaces are not rebroadcast on this interface (RNS announces_from_internal).",
+ "location_cmd_label": "Location command (optional)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Absolute path to an executable that prints latitude,longitude,height. Reticulum runs it for discovery announces (RNS location_cmd).",
+ "block_fast_flapping_label": "Block fast-flapping clients",
+ "block_fast_flapping_hint": "Ignore clients that connect and disconnect too quickly (RNS 1.3.9 BackboneInterface defaults).",
+ "fast_flapping_block_time_label": "Block time (minutes)",
+ "fast_flapping_threshold_label": "Threshold (seconds)",
+ "fast_flapping_grace_label": "Grace flaps",
"loopback_local_title": "Local / loopback",
"loopback_local_body": "Reticulum uses a shared instance internally. To talk to another RNS process on this host, use a TCP Client to 127.0.0.1 with the instance port, or use an external interface module in the interface path.",
"loopback_local_docs_hint": "See the Interfaces chapter for supported types.",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index a3002127..6a1d6bb7 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Solo se permite una interfaz I2P. Active primero el modo de transporte en Ajustes y añada I2P al final. No importe I2P desde un archivo de configuración ni la edite en el editor en bruto. Los cambios que dejen I2P en medio se reparan automáticamente al guardar y al iniciar.",
"i2p_transport_required": "Active el modo de transporte en Ajustes antes de añadir una interfaz I2P.",
"i2p_already_exists": "Ya existe una interfaz I2P. Elimínela antes de añadir otra.",
- "i2p_import_forbidden": "Las interfaces I2P no se pueden importar desde un archivo. Añada I2P solo desde Añadir interfaz."
+ "i2p_import_forbidden": "Las interfaces I2P no se pueden importar desde un archivo. Añada I2P solo desde Añadir interfaz.",
+ "mode_default_full": "Predeterminado (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Solicitudes de ruta recursivas",
+ "recursive_prs_hint": "Permite descubrimiento recursivo de rutas en esta interfaz sin importar el modo (RNS recursive_prs).",
+ "announces_from_internal_label": "Propagar announces desde interfaces internas",
+ "announces_from_internal_hint": "Si está desactivado, los announces recibidos en interfaces en modo internal no se reemiten aquí (RNS announces_from_internal).",
+ "location_cmd_label": "Comando de ubicación (opcional)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Ruta absoluta a un ejecutable que imprime latitude,longitude,height. Reticulum lo ejecuta para announces de discovery (RNS location_cmd).",
+ "block_fast_flapping_label": "Bloquear clientes de flapping rápido",
+ "block_fast_flapping_hint": "Ignora clientes que se conectan y desconectan demasiado rápido (valores por defecto de BackboneInterface en RNS 1.3.9).",
+ "fast_flapping_block_time_label": "Tiempo de bloqueo (minutos)",
+ "fast_flapping_threshold_label": "Umbral (segundos)",
+ "fast_flapping_grace_label": "Flaps de gracia"
},
"map": {
"title": "Mapa",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 49d58b2b..9111e01b 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Vain yksi I2P-liittymä on sallittu. Ota ensin välitystila käyttöön asetuksissa ja lisää I2P viimeiseksi. Älä tuo I2P:tä asetustiedostosta äläkä muokkaa sitä raakaeditorissa. Muutokset, jotka jättävät I2P:n keskelle, korjataan automaattisesti tallennuksessa ja käynnistyksessä.",
"i2p_transport_required": "Ota välitystila käyttöön asetuksissa ennen I2P-liittymän lisäämistä.",
"i2p_already_exists": "I2P-liittymä on jo olemassa. Poista se ennen uuden lisäämistä.",
- "i2p_import_forbidden": "I2P-liittymiä ei voi tuoda tiedostosta. Lisää I2P vain Lisää liittymä -sivulta."
+ "i2p_import_forbidden": "I2P-liittymiä ei voi tuoda tiedostosta. Lisää I2P vain Lisää liittymä -sivulta.",
+ "mode_default_full": "Oletus (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Rekursiiviset polkupyynnöt",
+ "recursive_prs_hint": "Sallii rekursiivisen polunlöydön tällä liittymällä tilasta riippumatta (RNS recursive_prs).",
+ "announces_from_internal_label": "Välitä announceja sisäisiltä liittymiltä",
+ "announces_from_internal_hint": "Kun pois päältä, internal-tilan liittymiltä saatuja announceja ei lähetetä uudelleen tällä liittymällä (RNS announces_from_internal).",
+ "location_cmd_label": "Sijaintikomento (valinnainen)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Absoluuttinen polku ohjelmaan, joka tulostaa latitude,longitude,height. Reticulum ajaa sen discovery-announceja varten (RNS location_cmd).",
+ "block_fast_flapping_label": "Estaa nopea flapping",
+ "block_fast_flapping_hint": "Ohittaa asiakkaat, jotka yhdistavat ja katkaisevat liian nopeasti (RNS 1.3.9 BackboneInterface-oletukset).",
+ "fast_flapping_block_time_label": "Estoaika (minuuttia)",
+ "fast_flapping_threshold_label": "Kynnys (sekuntia)",
+ "fast_flapping_grace_label": "Armo-flapit"
},
"map": {
"title": "Kartta",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 05d0d8ca..5b55dc24 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Une seule interface I2P est autorisée. Activez d'abord le mode transport dans les paramètres, puis ajoutez I2P en dernier. N'importez pas I2P depuis un fichier de configuration et ne la modifiez pas dans l'éditeur brut. Les changements qui laissent I2P au milieu sont réparés automatiquement à l'enregistrement et au démarrage.",
"i2p_transport_required": "Activez le mode transport dans les paramètres avant d'ajouter une interface I2P.",
"i2p_already_exists": "Une interface I2P existe déjà. Supprimez-la avant d'en ajouter une autre.",
- "i2p_import_forbidden": "Les interfaces I2P ne peuvent pas être importées depuis un fichier. Ajoutez I2P uniquement via Ajouter une interface."
+ "i2p_import_forbidden": "Les interfaces I2P ne peuvent pas être importées depuis un fichier. Ajoutez I2P uniquement via Ajouter une interface.",
+ "mode_default_full": "Par défaut (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Demandes de chemin récursives",
+ "recursive_prs_hint": "Autorise la discovery récursive de chemins sur cette interface quel que soit le mode (RNS recursive_prs).",
+ "announces_from_internal_label": "Propager les announces depuis les interfaces internes",
+ "announces_from_internal_hint": "Si désactivé, les announces reçues sur des interfaces en mode internal ne sont pas réémises ici (RNS announces_from_internal).",
+ "location_cmd_label": "Commande de localisation (optionnel)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Chemin absolu vers un exécutable qui affiche latitude,longitude,height. Reticulum l'exécute pour les announces de discovery (RNS location_cmd).",
+ "block_fast_flapping_label": "Bloquer le flapping rapide",
+ "block_fast_flapping_hint": "Ignore les clients qui se connectent et se déconnectent trop rapidement (défauts BackboneInterface RNS 1.3.9).",
+ "fast_flapping_block_time_label": "Durée de blocage (minutes)",
+ "fast_flapping_threshold_label": "Seuil (secondes)",
+ "fast_flapping_grace_label": "Flaps de grace"
},
"map": {
"title": "Carte",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 65a90927..f987faa8 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1348,7 +1348,26 @@
"i2p_requirements_body": "È consentita una sola interfaccia I2P. Abilita prima la modalità trasporto nelle Impostazioni, poi aggiungi I2P per ultima. Non importare I2P da un file di configurazione e non modificarla nell'editor grezzo. Le modifiche che lasciano I2P a metà elenco vengono riparate automaticamente al salvataggio e all'avvio.",
"i2p_transport_required": "Abilita la modalità trasporto nelle Impostazioni prima di aggiungere un'interfaccia I2P.",
"i2p_already_exists": "Esiste già un'interfaccia I2P. Rimuovila prima di aggiungerne un'altra.",
- "i2p_import_forbidden": "Le interfacce I2P non possono essere importate da un file. Aggiungi I2P solo tramite Aggiungi interfaccia."
+ "i2p_import_forbidden": "Le interfacce I2P non possono essere importate da un file. Aggiungi I2P solo tramite Aggiungi interfaccia.",
+ "mode_default_full": "Predefinito (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Richieste di percorso ricorsive",
+ "recursive_prs_hint": "Consente la discovery ricorsiva dei percorsi su questa interfaccia indipendentemente dalla modalita (RNS recursive_prs).",
+ "announces_from_internal_label": "Propagare gli announce dalle interfacce interne",
+ "announces_from_internal_hint": "Se disattivato, gli announce ricevuti su interfacce in modalita internal non vengono ritrasmessi qui (RNS announces_from_internal).",
+ "location_cmd_label": "Comando posizione (opzionale)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Percorso assoluto a un eseguibile che stampa latitude,longitude,height. Reticulum lo esegue per gli announce di discovery (RNS location_cmd).",
+ "block_fast_flapping_label": "Blocca client con flapping rapido",
+ "block_fast_flapping_hint": "Ignora client che si connettono e disconnettono troppo in fretta (default BackboneInterface RNS 1.3.9).",
+ "fast_flapping_block_time_label": "Tempo di blocco (minuti)",
+ "fast_flapping_threshold_label": "Soglia (secondi)",
+ "fast_flapping_grace_label": "Flap di grazia"
},
"map": {
"title": "Mappa",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 38a08215..dda72bed 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Er is slechts één I2P-interface toegestaan. Schakel eerst transportmodus in bij Instellingen en voeg I2P als laatste toe. Importeer I2P niet uit een configbestand en bewerk het niet in de ruwe editor. Wijzigingen die I2P middenin laten staan worden automatisch hersteld bij opslaan en opstarten.",
"i2p_transport_required": "Schakel transportmodus in bij Instellingen voordat je een I2P-interface toevoegt.",
"i2p_already_exists": "Er bestaat al een I2P-interface. Verwijder die voordat je er nog een toevoegt.",
- "i2p_import_forbidden": "I2P-interfaces kunnen niet uit een bestand worden geïmporteerd. Voeg I2P alleen toe via Interface toevoegen."
+ "i2p_import_forbidden": "I2P-interfaces kunnen niet uit een bestand worden geïmporteerd. Voeg I2P alleen toe via Interface toevoegen.",
+ "mode_default_full": "Standaard (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Recursieve padverzoeken",
+ "recursive_prs_hint": "Staat recursieve padontdekking toe op deze interface ongeacht de modus (RNS recursive_prs).",
+ "announces_from_internal_label": "Announces van interne interfaces doorgeven",
+ "announces_from_internal_hint": "Indien uit, worden announces ontvangen op internal-mode interfaces hier niet opnieuw uitgezonden (RNS announces_from_internal).",
+ "location_cmd_label": "Locatiecommando (optioneel)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Absoluut pad naar een uitvoerbaar bestand dat latitude,longitude,height print. Reticulum voert dit uit voor discovery-announces (RNS location_cmd).",
+ "block_fast_flapping_label": "Snelle flapping-clients blokkeren",
+ "block_fast_flapping_hint": "Negeert clients die te snel verbinden en verbreken (RNS 1.3.9 BackboneInterface-standaarden).",
+ "fast_flapping_block_time_label": "Blokkeertijd (minuten)",
+ "fast_flapping_threshold_label": "Drempel (seconden)",
+ "fast_flapping_grace_label": "Grace-flaps"
},
"map": {
"title": "Kaart",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 046f29b8..c84b2141 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1348,7 +1348,26 @@
"i2p_requirements_body": "Допускается только один интерфейс I2P. Сначала включите режим транспорта в настройках, затем добавьте I2P последним. Не импортируйте I2P из файла конфигурации и не редактируйте его в сыром редакторе. Изменения, оставляющие I2P не последним, автоматически исправляются при сохранении и запуске.",
"i2p_transport_required": "Включите режим транспорта в настройках перед добавлением интерфейса I2P.",
"i2p_already_exists": "Интерфейс I2P уже существует. Удалите его перед добавлением другого.",
- "i2p_import_forbidden": "Интерфейсы I2P нельзя импортировать из файла. Добавляйте I2P только через страницу добавления интерфейса."
+ "i2p_import_forbidden": "Интерфейсы I2P нельзя импортировать из файла. Добавляйте I2P только через страницу добавления интерфейса.",
+ "mode_default_full": "По умолчанию (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "Рекурсивные запросы путей",
+ "recursive_prs_hint": "Разрешает рекурсивный поиск путей на этом интерфейсе вне зависимости от режима (RNS recursive_prs).",
+ "announces_from_internal_label": "Распространять announces с внутренних интерфейсов",
+ "announces_from_internal_hint": "Если выключено, announces с интерфейсов в режиме internal не ретранслируются здесь (RNS announces_from_internal).",
+ "location_cmd_label": "Команда местоположения (необязательно)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "Абсолютный путь к исполняемому файлу, печатающему latitude,longitude,height. Reticulum запускает его для discovery-announces (RNS location_cmd).",
+ "block_fast_flapping_label": "Блокировать быстрый flapping",
+ "block_fast_flapping_hint": "Игнорирует клиентов, которые слишком быстро подключаются и отключаются (стандарты BackboneInterface RNS 1.3.9).",
+ "fast_flapping_block_time_label": "Время блокировки (минуты)",
+ "fast_flapping_threshold_label": "Порог (секунды)",
+ "fast_flapping_grace_label": "Льготные flaps"
},
"map": {
"title": "Карта",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 6322c02f..fb580ef9 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1296,7 +1296,26 @@
"i2p_requirements_body": "只允许一个 I2P 接口。请先在设置中启用传输模式,再将 I2P 添加为最后一个接口。不要从配置文件导入 I2P,也不要在原始配置编辑器中修改它。若 I2P 不在末尾,保存和启动时会自动修复。",
"i2p_transport_required": "添加 I2P 接口前请先在设置中启用传输模式。",
"i2p_already_exists": "已存在 I2P 接口。请先删除后再添加另一个。",
- "i2p_import_forbidden": "不能从文件导入 I2P 接口。请仅通过“添加接口”页面添加 I2P。"
+ "i2p_import_forbidden": "不能从文件导入 I2P 接口。请仅通过“添加接口”页面添加 I2P。",
+ "mode_default_full": "默认 (Full)",
+ "mode_full": "Full",
+ "mode_gateway": "Gateway",
+ "mode_access_point": "Access Point",
+ "mode_roaming": "Roaming",
+ "mode_boundary": "Boundary",
+ "mode_internal": "Internal",
+ "recursive_prs_label": "递归路径请求",
+ "recursive_prs_hint": "无论接口模式如何,都允许在此接口上进行递归路径发现(RNS recursive_prs)。",
+ "announces_from_internal_label": "传播来自内部接口的通告",
+ "announces_from_internal_hint": "关闭后,在 internal 模式接口上收到的通告不会在此接口上重播(RNS announces_from_internal)。",
+ "location_cmd_label": "位置命令(可选)",
+ "location_cmd_placeholder": "/path/to/gps-script",
+ "location_cmd_hint": "可执行文件的绝对路径,输出 latitude,longitude,height。Reticulum 会在发现通告时运行它(RNS location_cmd)。",
+ "block_fast_flapping_label": "阻止快速抖动客户端",
+ "block_fast_flapping_hint": "忽略连接与断开过快的客户端(RNS 1.3.9 BackboneInterface 默认值)。",
+ "fast_flapping_block_time_label": "封锁时间(分钟)",
+ "fast_flapping_threshold_label": "阈值(秒)",
+ "fast_flapping_grace_label": "宽限抖动次数"
},
"map": {
"title": "地图",

diff --git a/tests/backend/test_interface_editor.py b/tests/backend/test_interface_editor.py
index ab1ee533..82653c9b 100644
--- a/tests/backend/test_interface_editor.py
+++ b/tests/backend/test_interface_editor.py
@@ -93,3 +93,80 @@ def test_normalize_rnode_tcp_port_bracket_ipv6_with_port():
def test_normalize_rnode_tcp_port_non_tcp_unchanged():
assert InterfaceEditor.normalize_rnode_tcp_port("/dev/ttyUSB0") == "/dev/ttyUSB0"
+
+
+def test_normalize_interface_mode_aliases():
+ assert InterfaceEditor.normalize_interface_mode("internal") == "internal"
+ assert InterfaceEditor.normalize_interface_mode("GW") == "gateway"
+ assert InterfaceEditor.normalize_interface_mode("ap") == "access_point"
+ assert InterfaceEditor.normalize_interface_mode("evil") is None
+ assert InterfaceEditor.normalize_interface_mode("") is None
+
+
+def test_apply_interface_mode_rejects_unknown():
+ details = {}
+ err = InterfaceEditor.apply_interface_mode(details, {"mode": "not-a-mode"})
+ assert err is not None
+ assert "mode" not in details
+
+
+def test_apply_interface_mode_canonicalizes():
+ details = {}
+ assert InterfaceEditor.apply_interface_mode(details, {"mode": "internal"}) is None
+ assert details["mode"] == "internal"
+ assert InterfaceEditor.apply_interface_mode(details, {"mode": "gw"}) is None
+ assert details["mode"] == "gateway"
+
+
+def test_apply_yes_no_option_recursive_prs():
+ details = {}
+ assert (
+ InterfaceEditor.apply_yes_no_option(
+ details, {"recursive_prs": True}, "recursive_prs"
+ )
+ is None
+ )
+ assert details["recursive_prs"] == "yes"
+ assert (
+ InterfaceEditor.apply_yes_no_option(
+ details,
+ {"announces_from_internal": False},
+ "announces_from_internal",
+ )
+ is None
+ )
+ assert details["announces_from_internal"] == "no"
+
+
+def test_validate_location_cmd_rejects_shell_metacharacters():
+ assert InterfaceEditor.validate_location_cmd("/usr/bin/true") is None
+ assert InterfaceEditor.validate_location_cmd("~/bin/gps.sh") is None
+ assert InterfaceEditor.validate_location_cmd("relative/path") is not None
+ assert InterfaceEditor.validate_location_cmd("/tmp/evil;rm -rf /") is not None
+ assert InterfaceEditor.validate_location_cmd("/tmp/$(id)") is not None
+ assert InterfaceEditor.validate_location_cmd("/tmp/../etc/passwd") is not None
+
+
+def test_apply_location_cmd_normalizes_home():
+ details = {}
+ err = InterfaceEditor.apply_location_cmd(details, {"location_cmd": "~/gps-loc"})
+ assert err is None
+ assert details["location_cmd"].endswith("gps-loc")
+ assert details["location_cmd"].startswith("/")
+
+
+def test_sanitize_imported_rns_options_rejects_bad_mode():
+ body = {"mode": "warehouse"}
+ assert InterfaceEditor.sanitize_imported_rns_options(body) is not None
+
+
+def test_sanitize_imported_rns_options_accepts_internal():
+ body = {
+ "mode": "internal",
+ "recursive_prs": "yes",
+ "announces_from_internal": "no",
+ }
+ assert InterfaceEditor.sanitize_imported_rns_options(body) is None
+ assert body["mode"] == "internal"
+ assert body["recursive_prs"] == "yes"
+ assert body["announces_from_internal"] == "no"

diff --git a/tests/backend/test_interface_options.py b/tests/backend/test_interface_options.py
index 3ca8cf0c..3540cd0f 100644
--- a/tests/backend/test_interface_options.py
+++ b/tests/backend/test_interface_options.py
@@ -737,4 +737,94 @@ async def test_i2p_connectable_can_be_disabled(temp_dir):
saved = config["interfaces"]["I2POut"]
assert saved["connectable"] == "False"
assert saved["peers"] == ["abcdef.b32.i2p"]
- assert list(config["interfaces"].keys())[-1] == "I2POut"
+
+
+@pytest.mark.asyncio
+async def test_internal_mode_and_rns_bool_options_persist(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ free_port = _free_port("tcp")
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "InternalTCP",
+ "type": "TCPServerInterface",
+ "listen_ip": "127.0.0.1",
+ "listen_port": free_port,
+ "mode": "internal",
+ "recursive_prs": True,
+ "announces_from_internal": False,
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 200, body
+ saved = config["interfaces"]["InternalTCP"]
+ assert saved["mode"] == "internal"
+ assert saved["recursive_prs"] == "yes"
+ assert saved["announces_from_internal"] == "no"
+
+
+@pytest.mark.asyncio
+async def test_rejects_unknown_interface_mode(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ free_port = _free_port("tcp")
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "BadMode",
+ "type": "TCPServerInterface",
+ "listen_ip": "127.0.0.1",
+ "listen_port": free_port,
+ "mode": "warehouse",
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 422, body
+ assert "mode" in body["message"].lower()
+ assert "BadMode" not in config["interfaces"]
+
+
+@pytest.mark.asyncio
+async def test_rejects_unsafe_location_cmd(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ free_port = _free_port("tcp")
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "Disco",
+ "type": "TCPServerInterface",
+ "listen_ip": "127.0.0.1",
+ "listen_port": free_port,
+ "discoverable": "yes",
+ "mode": "gateway",
+ "location_cmd": "/tmp/gps; rm -rf /",
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 422, body
+ assert "location_cmd" in body["message"]
+
+
+@pytest.mark.asyncio
+async def test_backbone_listener_fast_flapping_options(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ free_port = _free_port("tcp")
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "BackboneFlap",
+ "type": "BackboneInterface",
+ "listen_ip": "127.0.0.1",
+ "listen_port": free_port,
+ "block_fast_flapping": True,
+ "fast_flapping_block_time": 60,
+ "fast_flapping_threshold": 15,
+ "fast_flapping_grace": 3,
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 200, body
+ saved = config["interfaces"]["BackboneFlap"]
+ assert saved["block_fast_flapping"] == "yes"
+ assert saved["fast_flapping_block_time"] == 60
+ assert saved["fast_flapping_threshold"] == 15
+ assert saved["fast_flapping_grace"] == 3

diff --git a/tests/backend/test_rnstatus_formatting.py b/tests/backend/test_rnstatus_formatting.py
index b1b7b57d..e7554a3f 100644
--- a/tests/backend/test_rnstatus_formatting.py
+++ b/tests/backend/test_rnstatus_formatting.py
@@ -30,3 +30,37 @@ def test_speed_str_bitrate_not_scaled():
assert speed_str(100) == "100.00 bps"
assert speed_str(5_000_000) == "5.00 Mbps"
assert speed_str(8_000_000) == "8.00 Mbps"
+
+
+def test_rnstatus_mode_labels_match_rns_constants():
+ from unittest.mock import MagicMock
+
+ import RNS
+
+ from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
+
+ iface = RNS.Interfaces.Interface.Interface
+ cases = {
+ iface.MODE_FULL: "Full",
+ iface.MODE_POINT_TO_POINT: "Point-to-Point",
+ iface.MODE_ACCESS_POINT: "Access Point",
+ iface.MODE_ROAMING: "Roaming",
+ iface.MODE_BOUNDARY: "Boundary",
+ iface.MODE_GATEWAY: "Gateway",
+ iface.MODE_INTERNAL: "Internal",
+ }
+ handler = RNStatusHandler(MagicMock())
+ for mode_value, label in cases.items():
+ status = handler.get_status(
+ stats={
+ "interfaces": [
+ {
+ "name": "TestInterface",
+ "status": True,
+ "mode": mode_value,
+ },
+ ],
+ },
+ include_local_blackhole=False,
+ )
+ assert status["interfaces"][0]["mode"] == label, mode_value

diff --git a/tests/frontend/AddInterfaceOptions.test.js b/tests/frontend/AddInterfaceOptions.test.js
index cc0b9a72..1f235e97 100644
--- a/tests/frontend/AddInterfaceOptions.test.js
+++ b/tests/frontend/AddInterfaceOptions.test.js
@@ -325,4 +325,77 @@ describe("AddInterfacePage.vue interface options", () => {
expect(ToastUtils.error).toHaveBeenCalledWith(expect.stringContaining("already in use"));
});
+
+ it("sends internal mode and recursive_prs / announces_from_internal", async () => {
+ const wrapper = mountPage();
+
+ wrapper.vm.newInterfaceName = "InternalLAN";
+ wrapper.vm.newInterfaceType = "TCPServerInterface";
+ wrapper.vm.newInterfaceListenIp = "127.0.0.1";
+ wrapper.vm.newInterfaceListenPort = 4242;
+ wrapper.vm.sharedInterfaceSettings.mode = "internal";
+ wrapper.vm.sharedInterfaceSettings.recursive_prs = true;
+ wrapper.vm.sharedInterfaceSettings.announces_from_internal = false;
+
+ await wrapper.vm.saveInterface();
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "/api/v1/reticulum/interfaces/add",
+ expect.objectContaining({
+ mode: "internal",
+ recursive_prs: true,
+ announces_from_internal: false,
+ })
+ );
+ });
+
+ it("sends BackboneInterface fast-flapping options in listener mode", async () => {
+ const wrapper = mountPage();
+
+ wrapper.vm.newInterfaceName = "Backbone";
+ wrapper.vm.newInterfaceType = "BackboneInterface";
+ wrapper.vm.newInterfaceBackboneListenMode = true;
+ wrapper.vm.newInterfaceBackboneListenIp = "0.0.0.0";
+ wrapper.vm.newInterfaceBackboneListenPort = 5151;
+ wrapper.vm.newInterfaceBlockFastFlapping = false;
+ wrapper.vm.newInterfaceFastFlappingBlockTime = 30;
+ wrapper.vm.newInterfaceFastFlappingThreshold = 10;
+ wrapper.vm.newInterfaceFastFlappingGrace = 2;
+
+ await wrapper.vm.saveInterface();
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "/api/v1/reticulum/interfaces/add",
+ expect.objectContaining({
+ block_fast_flapping: false,
+ fast_flapping_block_time: 30,
+ fast_flapping_threshold: 10,
+ fast_flapping_grace: 2,
+ })
+ );
+ });
+
+ it("sends discovery location_cmd when discoverable", async () => {
+ const wrapper = mountPage();
+
+ wrapper.vm.newInterfaceName = "TCPS";
+ wrapper.vm.newInterfaceType = "TCPServerInterface";
+ wrapper.vm.newInterfaceListenIp = "0.0.0.0";
+ wrapper.vm.newInterfaceListenPort = 4242;
+ wrapper.vm.discovery.discoverable = true;
+ wrapper.vm.discovery.discovery_name = "Public";
+ wrapper.vm.discovery.location_cmd = "/usr/local/bin/gps-loc";
+ wrapper.vm.sharedInterfaceSettings.mode = "gateway";
+
+ await wrapper.vm.saveInterface();
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "/api/v1/reticulum/interfaces/add",
+ expect.objectContaining({
+ discoverable: "yes",
+ location_cmd: "/usr/local/bin/gps-loc",
+ mode: "gateway",
+ })
+ );
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────